从闭包角度理解 React Hooks
函数组件每次渲染其内部都有它自己的 props、state、事件处理函数 ...
看下面这个例子。
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={handleAlertClick}>
Show alert
</button>
</div>
);
}
- 连续点击 3 次 Click me,让 count 更新到 3
- 然后点击一下 Show alert
- 然后点击 2 次 Click me,让 count 更新到 5
最终点击了 Show alert 后,等待 3s 最终 alert 弹出的 count 是 3。
因为点击了 Show alert 之后,事件处理函数内部会开启一个定时器,间隔 3s 后,内部的回调函数就会执行,里面的 count 变量就会去那一次函数组件渲染过程中产生的闭包中寻找,其值为 3。
每次渲染都会有一个全新的 effect 回调
先回顾一下 useEffect
的执行时机,当函数组件渲染时,调用 useEffect
会绑定一个回调,这个回调会在真实 DOM 已经渲染到页面中执行,所以将这个回调称为 effect 副作用。
分析一下,下面两个例子中,如果连续点击 5 次按钮,最终打印结果是什么?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
首先函数组件初次渲染,绑定 effect 回调,当真实 DOM 渲染到页面后,effect 回调会执行,等待 3s 后定时器回调中的 count 会从闭包中拿到,打印 0。
连续点击 5 次按钮,内部的 count 状态(useState 返回的结果)会依次变为 1、2、3、4、5,同理,每次重新渲染组件的时候,绑定的 effect 回调中,定时器回调中取到的 count 值也是当前那次组件渲染时保存在闭包中的 count。
在执行定时器回调时,每次回调中的 count 都会从当前那次闭包中保存的 count 从去取,点击 5 次按钮,可以理解为闭包中保存了 5 个不同的 count,因此定时器回调执行后,会依次打印 1、2、3、4、5。
function Counter() {
const [count, setCount] = useState(0);
const latestCount = useRef(count);
useEffect(() => {
latestCount.current = count;
setTimeout(() => {
console.log(`You clicked ${latestCount.current} times`);
}, 3000);
});
// ...
在这个例子中使用了 useRef
,你需要知道组件每次渲染时,调用 useRef
都会返回同一个对象,这个对象在组件挂载期间是一直保持不变的。
当点击 5 次按钮后,ref 对象上的 current 属性会变为 5,定时器的回调访问的 latestCount.current
就是直接从 ref 对象的 current 属性上拿的,所以最终会依次会打印 5、5、5、5、5。
这两个例子的区别就是,前一个例子中定时器回调是从当前那次渲染时产生的闭包中拿值,后一个例子是从 ref 对象的 current 属性上拿值。
因此当我们使用 Hooks 去模拟类组件的 componentDidMount 和 componentDidUpdate 时,类组件的 props
和 state
是保存在组件实例 this
上的,其表现行为就是上面例子中的第二个,需要将 props 和 state 挂载到 ref 对象上。
effect 的清除
回顾一下 effect 回调中的 cleanup。
useEffect(() => {
// cleanup
return () => {}
})
它的执行时机是在每次组件更新后,真实 DOM 渲染到页面中后,会执行 cleanup 清除上一次的副作用,然后再执行当前 effect 回调。
cleanup 在组件首次渲染的时候并不会执行,因为组件首次挂载时并没有之前的副作用。
在组件第二次渲染后,会先执行 cleanup,这个回调是上一次组件渲染的时候 effect 回调中返回出去的函数,因此 cleanup 内部访问到的所有 state、props 等变量都是组件第一次渲染时保存在闭包中的数据。
cleanup 执行后,清除上一次的副作用,然后才会执行本次的 effect 回调,并且又会返回出去保存了本次渲染中 state 和 props 新的 cleanup,它会在下一次组件副作用执行之前清除本次渲染产生的副作用。
合理使用 deps 依赖数组避免每次都执行 effect
useEffect
中第二个参数可以传入一个 deps 依赖数组,可以使用它来告诉 React,你的 effect 回调中使用了哪些数据,函数组件下一次渲染的时候,会浅比较依赖数组中的元素,只有当某个元素发生变化时,才会执行 effect 回调。
function Greeting({ name }) {
const [counter, setCounter] = useState(0);
useEffect(() => {
document.title = 'Hello, ' + name;
});
return (
<h1 className="Greeting">
Hello, {name}
<button onClick={() => setCounter(counter + 1)}>
Increment
</button>
</h1>
);
}
上面例子中,当我们没有添加 deps 依赖数组的时候,函数组件每次渲染后都会执行一次 effect 回调,很明显这是没有必要的。
你只需要将 deps 依赖数组中添加上 name 数据,这样 effect 回调只有在 name 发生变化时才会执行。
useEffect(() => {
document.title = 'Hello, ' + name;
}, [name]);
由于函数组件每次渲染时,都会有本次渲染时自己的 state 和 props 等数据,初学者很可能出现下面的错误。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
我们的直觉是只在组件首次挂载的时候执行副作用开启一个循环定时器,在内部调用 setCount
,每间隔 1s,让 count 递增一次。
但实际的情况是,页面中的 count 变为 1 后就不会发生变化了,原因很简单,循环定时器回调中拿到的 count 是组件首次渲染时保存在闭包中的 count,其值为 1,因此无论循环定时器执行多少次回调,都相当于执行 setCount(0 + 1)
,自然页面中的 count 变化到 1 后就不会变化了。
为了解决这个问题,你或许会像下面这样做。
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
在 deps 中添加了 count 之后,页面中的 count 可以进行递增了,但这段代码的实现逻辑并不是我们想要的,因为 count 递增的原理是每次在组件渲染后,执行副作用开启一个新的定时器去调用 setCount
,在下一次组件重新渲染的时候,又会通过 cleanup 来清除上一次的定时器,然后又创建一个新的定时器。
count 递增的效果实际上就是不断重复的开启新的定时器,去执行 setCount
,然后清除旧的定时器,来实现的。
我们真正的需求是,在组件首次渲染的时候,只开启一个循环定时器,来循环调用 setCount
实现递增效果。因此 deps 依赖数组必须为空数组,你可以像下面这样做。
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
这里把 deps 数组设置为空数组,因为 effect 回调中并没有依赖外部任何数据。
调用 setCount(c => c + 1)
相当于告诉 React 取到组件当前的 count,让它递增 1。
函数式更新
前面说到了使用 setCount(c => c + 1)
的方式来告诉 React 执行一个函数来更新状态,不同于 setCount(count + 1)
,后者是需要拿到当前 count 的值并且 + 1 后,告诉 React 你需要将状态更新为具体的什么值。
下面继续升级一下 Counter 的需求,要求 Counter 以指定的公差进行递增。
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [step]);
return (
<>
<h1>{count}</h1>
<input value={step} onChange={e => setStep(Number(e.target.value))} />
</>
);
}
当我们改变了 step 之后,函数组件重新渲染,然后执行 cleanup 清除上一次的定时器,然后重启开启一个定时器,以 step 的公差进行递增。
我们还可以继续改进,让 effect 和 step 也解耦,就算 step 改变也不需要重启定时器,这时候就需要用到 useReducer
了。
const initialState = {
count: 0,
step: 1,
};
function reducer(state, action) {
const { type, payload } = action;
switch (type) {
case "tick": {
return { ...state, count: state.count + state.step };
}
case "step": {
return { ...state, step: payload };
}
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' }); // Instead of setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return (
<>
<h1>{count}</h1>
<input onChange={e => dispatch({ type: "step", payload: +e.target.value })} />
</>
);
}
你需要知道 useReducer
返回的 dispatch
和 useRef
返回的对象一样,它们在整个组件的生命周期内都会保持不变。
让状态的处理逻辑与整个组件接耦,我们只需要通过 dispatch
将任务派发给 reducer
,让它来对状态进行处理即可。
所以,当你的代码中如果有 setState(c => c + 1)
这样的代码,你可以考虑使用 useReducer
。
另外 useReducer
还可以传入第三个参数,是一个函数,一般用于接收 props 来计算生成初始状态。
function Counter({step = 1}) {
const [state, dispatch] = useReducer(reducer, initialState, (state) => ({...state, step}));
// ...
总结
看完本篇文章,你需要知道:
闭包是如何体现在函数组件中的
函数组件每一次渲染都会产生一个闭包来保存当前这次渲染中回调函数所用到的组件的 props 和 state。你要记住那个例子就是 count 递增 3 次,然后 alert 开启一个定时器回调打印 count 之前再递增 2 次,最终打印的结果是开启定时器渲染那次的 count。
effect 回调与 cleanup 的执行时机
effect
回调会在每次组件渲染后,真实 DOM 已经渲染到页面中后进行回调cleanup
会在组件第二次渲染后,真实 DOM 已经渲染到页面中后调用,用来清除上一次的副作用,然后才会执行本次的副作用
useEffect 中每次组件渲染都会有一个全新的 effect 回调
函数组件每次渲染,都会重新执行 useEffect
传入一个新的 effect 回调函数对象,并且内部使用到的 state 与 props 都会保存在本次渲染产生的闭包中。
cleanup 中访问到的 state 和 props 是组件上一次渲染中保存在闭包中的值
由于 cleanup 在组件首次渲染后并不会调用,从第二次开始,每次组件渲染后,会在本次副作用执行之前,调用 cleanup 来清除上一次渲染的副作用,其内部访问的 state 和 props 都是来自第一次渲染时闭包中保存的值。
useEffect 中依赖数组的作用
React 会浅比较依赖数组中元素的变化,如果发生变化,那么就会执行 cleanup 清除上一次的副作用,并执行 effect 回调,让本次渲染的副作用生效。
如果依赖数组为空数组 []
,那么副作用只会在函数组件首次渲染时执行,cleanup
会在函数组件销毁后执行。
如何使用 useEffect 实现 componentDidMount 和 componentDidUpdate
const useDidMount = (cb) => {
useEffect(() => {
cb();
}, []);
}
const useDidUpdate = (cb) => {
const mountedRef = useRef(false);
useEffect(() => {
const isMounted = mountedRef.current;
if (isMounted) {
cb();
} else {
mountedRef.current = true;
}
});
}